常见的数据库 schema 变更错误
术语
三种类型的数据库迁移错误
案例1. Schema 不匹配
案例2. 滥用 IF [NOT] EXISTS
案例3. 遇到
statement_timeout
(语句超时)案例4. 无限大的变更
案例5. 获得一个独占锁 + 在事务中等待
案例6. 一个有 DDL + 大量 DML 的事务
案例7. 长时间等待获取独占锁 ⇒ 阻断他人
案例8. 不小心创建了一个 FK
案例9. 不小心删除了一个 FK
案例10. 不小心增加了一个
CHECK
约束条件案例11. 不小心添加了
NOT NULL
案例12. 不小心改变了列的数据类型
案例13. 粗心的
CREATE INDEX
案例14. 粗心的
DROP INDEX
案例15. 重命名对象
案例16. 添加一个带有
DEFAULT
的列案例17.
CREATE INDEX CONCURRENTLY
的遗留问题案例18. 为大表添加 4 字节的整数主键
建议
术语
「增量的」:变更是分步骤进行的;
「可逆的」:有可能「撤销」任何改变,回到 schema 的原始状态(和数据;在某些情况下,这可能是困难的或不可能的);
「可版本化的」:使用某种版本控制系统(如Git)。
- DML - 数据库操作语言(SELECT / INSERT / UPDATE / DELETE 等等)
三种数据库迁移错误
并发相关的错误。这是最大的一类,通常决定了一个应用 DBA 经验的重要部分。一些例子(目前跳过细节;我们很快就会谈论它们):
未能获得锁
一次性更新了太多的行
获得了一个排他性的锁,并使事务长时间开放 与步骤的正确性有关的错误 -- 逻辑问题。例子:
意外的 Schema 偏差
Schema / 应用程序代码不匹配
意外的数据 杂项 -- 与某些特定数据库功能的实现或特定数据库的配置有关的错误,例如:
遇到了 statement_timeout
在可以增长的表中使用 4 字节的整数主键
忽视 VACUUM 行为和臃肿风险
案例1. Schema 不匹配
create table t1 ();
ERROR: relation "t1" already exists
案例2. 滥用 IF [NOT] EXISTS
create table if not exists t1();
xmin
、xmax
和 ctid
,你可以在 Postgres 文档中阅读它们,「5.5. 系统列」https://www.postgresql.org/docs/current/ddl-system-columns.html ,所以每一行总是有几列;试试:insert into t1 select; select ctid, xmin, xmax from t1;
。IF [NOT] EXISTS
的情况。当然,保持所有环境中的 schema 都是最新的,并尊重所有观察到的错误,不忽视它们,不选择诸如 IF [NOT] EXISTS
这样的 「变通 」路径,可以说是良好的工程实践。案例3. 遇到 statement_timeout
ERROR: canceling statement due to statement timeout
statement_timeout
设置,表越小,查询的执行速度就越快。这很容易导致只在生产环境中达到超时的情况。案例4. 无限大的变更
UPDATE
或 DELETE
是一个坏主意,大家都知道。但为什么呢?test=# explain (buffers, analyze) update t1
set val = replace(val, '0159', 'OiSg');
QUERY PLAN
--------------------------------------------------------------------------------------------------------
Update on t1 (cost=0.00..189165.00 rows=10000000 width=42) (actual time=76024.507..76024.508 rows=0 loops=1)
Buffers: shared hit=60154265 read=91606 dirtied=183191 written=198198
-> Seq Scan on t1 (cost=0.00..189165.00 rows=10000000 width=42) (actual time=0.367..2227.103 rows=10000000 loops=1)
Buffers: shared read=64165 written=37703
Planning:
Buffers: shared hit=17 read=1 dirtied=1
Planning Time: 0.497 ms
Execution Time: 76024.546 ms
(8 rows)
Time: 76030.399 ms (01:16.030)
在一个事务中修改太多的行(在这里,我们有一个单查询事务),意味着这些行将被锁定修改,直到我们的事务完成。这可能会影响其他事务,可能会恶化用户体验。例如,如果一些用户试图修改其中一个被锁定的行,他们的修改尝试可能需要很长时间。 如果检查指针没有得到很好的调整(例如, max_wal_size
的值是默认的,1GB
),在这样一个大规模的操作中,检查点可能会非常频繁地出现。由于full_page_writes
被打开(默认),这将导致 WAL 数据的过度生成。此外,如果磁盘系统不够强大,检查点产生的 IO 可能会使磁盘的写入能力饱和,导致总体性能下降。 如果我们的大量操作是基于一些索引和数据修改以随机顺序发生在页面上,多次重新访问一个页面,在未调整的检查指针和频繁的检查点下,一个缓冲区可能会通过多个脏-清周期,意味着我们有多余的写操作。 最后,我们在这里可能有两种类型的 清理/膨胀 问题。首先,如果我们在一个事务中用 UPDATE
或DELETE
改变了很多图元,就会产生大量的死图元。即使自动真空功能很快清理了它们,这样大量的死图元也很有可能直接转化为膨胀,导致额外的磁盘消耗和潜在的性能下降。第二,在漫长的事务过程中,自动真空系统无法清理任何在我们的事务过程中成为死的表的死图元--直到这个事务停止。
考虑把工作分成几批,每一批都是一个单独的事务。如果你是在 OLTP 背景下工作(移动或网络应用),应该确定批处理的大小,以便任何批处理的预期不会超过1秒。要了解为什么我推荐 1 秒作为批处理的软门槛,请阅读文章「什么是慢的SQL查询?」https://postgres.ai/blog/20210909-what-is-a-slow-sql-query 注意 VACUUMing(真空)- 调整自动真空和/或考虑在处理一定数量的批处理后使用显式 VACUUM
调用。
案例5. 在事务中获得一个 独占锁 + 等待
UPDATE
或 DELETE
隐式或通过 SELECT ... FOR UPDATE
显式)或数据库对象(例如:在事务块内成功的 ALTER TABLE
会锁定该表并保持该锁直到事务结束)。如果你需要了解更多关于 Postgres 中锁的信息,请阅读 Marco Slot 写的文章 「PostgreSQL是个好东西,除了它阻塞的时候:了解锁」https://www.citusdata.com/blog/2018/02/15/when-postgresql-blocks/。begin;
alter table t1 add column c123 int8;
-- do something inside or outside of the database (or do nothing)
commit;
注意事项记住:在一个事务中获得的任何锁都会被保留到这个事务的最后。只有当事务结束时,才会通过 COMMIT 或 ROLLBACK 释放它。
案例6. 一个有 DDL + 大量 DML 的事务
begin;
alter table t1 add column c123 int8;
copy ... -- load a lot of data, taking some time
commit;
DML 永远不应该在 DDL 之后进行,除非它们都处理一些新创建的表。
通常明智的做法是将 DDL 和 DML 活动分成不同的事务/迁移步骤。
最后,请记住,大规模的变化应该分批进行。每个批次都是一个单独的事务 -- 所以如果你遵循这个规则,并且在 CI/CD 管道中测试变化时使用了大量的数据,那么你应该不会遇到这种情况。
案例7. 长时间等待获取独占锁 ⇒ 阻塞他人
statement_timeout
和 lock_timeout
)被设置为0(默认)或相当大(>>1s),我们就会阻塞对这个表的所有查询,甚至 SELECT。我在「零延迟的 Postgres schema 迁移需要这个:lock_timeout 和 retries」https://postgres.ai/blog/20210923-zero-downtime-postgres-schema-migrations-lock-timeout-and-retries 一文中谈到了这个特殊的问题。CREATE/DROP INDEX CONCURRENTLY
的迁移(在下面讨论),你应该有低 lock_timeout
的重试逻辑,正如我在文章中描述的那样。这是一个每个人都需要拥有的基本机制 -- 我想在某个时候,无论是 Postgres 还是流行的 DB schema 迁移工具都会实现它,所以应用 DBA 的世界会变得更好。案例8. 不小心创建一个 FK
alter table orders add constraint fk_orders_customers foreign key (customer_id) references customers (id);
这里我们可以有两个我们已经讨论过的问题:
两个表的元数据需要调整,所以我们需要两个锁 -- 如果一个锁获得了,但是第二个锁没有获得,而我们又在等待它,我们就会遇到阻塞问题(对两个表都是如此!)。
当一个外键被引入时,Postgres 需要为引用表中使用的每个值检查该值在被引用表中是否存在。这可能需要一些时间 -- 而在这段时间里,锁将被保留。
使用两步方法:首先,用
not valid
选项定义外键,然后,在一个单独的事务中,运行alter table ... validate constraint ...;
当第一个 ALTER 的时候,不要忘记我们上面讨论的重试逻辑。注意,需要两个表级的独占锁。
案例9. 不小心删除一个 FK
lock_timeout
的重试逻辑可以使我们免于阻塞问题的风险。案例10. 不小心添加了一个 CHECK
约束
CHECK
约束是一个强大的、真正有用的机制。我非常喜欢它们,因为它们可以帮助我们定义一个严格的数据模型,在这个模型中,主要的检查都是在数据库方面完成的,所以我们有一个可靠的高数据质量保证。CHECK
约束的问题与添加外键约束非常相似 -- 但它更简单,因为我们只需要处理一个表(不幸的是,你不能在 CHECK
约束中引用其他表)。当我们在一个大表上添加这样的约束时,需要进行全表扫描以确保没有违反约束的情况。这需要时间,在此期间,我们有一个部分的停机时间 -- 不可能对表进行查询。(还记得 DDL + 大量数据变化的情况吗?这里我们有一个子案例)。not valid
选项来定义这个约束。接下来,在一个单独的事务中,我们执行验证:alter table ... validate constraint ...;
。ALTER
命令时使用低 lock_timeout
的重试逻辑)。案例11. 不小心添加 NOT NULL
使用一个带有表达式的 CHECK
约束:alter table ... add constraint ... (col1 is not null)使用一个「常规 」的 NOT NULL
约束:alter table ... alter column c1 set not null
CHECK
约束不同,常规 NOT NULL 的定义不能以「在线方式」进行,分两步进行,正如我们看到的 FK 和 CHECK。NOT NULL
才适用 -- 当我们在一个有大量数据的现有表上定义(或重新定义)一个主键时。在这种情况下,我们必须在主键定义中使用的所有列上设置 NOT NULL
,否则我们会突然进行全表扫描,以隐含的方式安装 NOT NULL
约束。在 Postgres 11 之前,没有「官方」的方法来避免部分停机。唯一的方法是确保没有值违反约束,并明确编辑系统目录,当然,这并不推荐。 从 Postgres 11 开始,如果 NOT NULL
必须安装在一个新的列上(当我们谈论 PK 定义时,经常出现这种情况),我们可以使用一个不错的技巧:首先,添加一列
not null default -1
(考虑到该列是int8
类型的;在这里我们受益于 Postgres 11中引入的一个伟大的优化 -- 快速创建具有默认值的列;我们的NOT NULL
是自动引入和执行的,因为所有现有的记录在新的列中得到了-1
,所以没有NULL
值存在)。然后用数值回填所有现有的行。
最后,删除 DEFAULT
--NOT NULL
约束将保留在其位置上。最后,在 Postgres 12 中,另一项伟大的优化使得在任何列上以完全「在线」的方式引入一个常规的、传统的 NOT NULL
成为可能。需要做的是:首先,创建一个带有(... is not null)表达式的CHECK
约束。接下来,定义一个常规的NOT NULL
约束 -- 由于新的优化,强制扫描将被跳过,因为现在 Postgres 明白没有 NULL 的存在,这要感谢CHECK
约束。最后,CHECK
约束可以被放弃,因为它对于我们的常规 NOT NULL 约束来说是多余的。
案例12. 不小心改变了列的数据类型
alter table t1 alter column c2 type int8;
时,你有可能得到一个完整的表重写。案例13. 粗心的 CREATE INDEX
CREATE INDEX
,除非它是一个全新表的索引,还没有人在使用。CREATE INDEX CONCURRENTLY
。虽然,有一些注意事项需要记住:它的速度大约是普通 CREATE INDEX 的两倍 不能在事务块中使用 如果它失败了(如果你建立的是唯一索引,机会不是 0),会给表留下一个无效的索引定义,所以: 部署系统必须准备好重试创建索引 失败后,需要进行清理
案例14. 粗心的 DROP INDEX
CREATE INDEX
不同,DROP INDEX
的唯一问题是,它可能导致锁的获取问题(见案例 7)。虽然对于 ALTER
来说,没有什么可以用来与长期等待或失败的锁获取相关的问题,但对于 DROP INDEX
来说,Postgres 有 DROP INDEX CONCURRENTLY
。这看起来是不平衡的,但是可能可以解释为,与 ALTER
相比,重新创建索引可能是更经常需要的(另外,REINDEX CONCURRENTLY
是在 Postgres 12 中添加的)。案例15. 重命名对象
CREATE INDEX CONCURRENTLY
。以及我们需要分批处理的事实。以及避免长时间的独占锁。还有我们已经讨论过的所有其他花里胡哨的功能...... ) 理想情况下,应用程序代码的部署 -- 在我们拥有的所有节点上,可能是成百上千个节点 -- 应该发生在同一个事务中,所以当重命名提交时,所有应用节点已经有了新版本的代码。创建一个新的列(有一个新的名字)
定义一个触发器来镜像旧列的值
回填(分批进行,控制死的图元和臃肿)
将你的应用程序切换到使用新的列
当完全切换时,丢弃旧列
案例16. 添加一个带有 DEFAULT
的列
案例17. CREATE INDEX CONCURRENTLY
的遗留问题
CREATE INDEX CONCURRENTLY
之前,我们应该检查 pg_indexes
:test=# select indexrelid, indexrelid::regclass as indexname, indisvalid
from pg_index
where not indisvalid and indexrelid::regclass::text = 'mytable_title_idx';
indexrelid | indexname | indisvalid
------------+-------------------+------------
26401 | mytable_title_idx | f
(1 row)
案例18. 大表的 4 字节整数主键
int4
的 PK 是没有意义的 -- 这里的好消息是,大多数流行的框架,如 Rails,Django 已经转而使用 int8
。我个人建议始终使用 int8
,即使你现在不指望你的表会增长 -- 如果项目成功,情况可能会改变。int4
的人,我有一个问题。考虑一个有 10 亿行的表,有两列 -- 一个整数和一个时间戳。你是否会看到该表的两个版本(id int4, ts timestamptz)
和(id int8, ts timestamptz)
在大小上的差异。答案可能会让你感到惊讶(在这种情况下,请阅读「列式俄罗斯方块」https://stackoverflow.com/questions/2966524/calculating-and-saving-space-in-postgresql/7431468#7431468)。一些建议
测试、测试、测试。在测试中使用真实的数据量。正如已经提到的,数据库实验室引擎(DLE)https://github.com/postgres-ai/database-lab-engine 对它非常有用。 在测试时,注意独占锁的持有时间。看看 DLE 的组件「DB Migration Checker」https://postgres.ai/docs/db-migration-checker,它可以帮助你在 CI/CD 管道中自动进行这种测试。 对于扩展的锁分析,使用我的博客文章中关于锁树分析的片段 https://postgres.ai/blog/20211018-postgresql-lock-trees。 为部署建立更好的自动化。有很多很好的自动化例子,这些助手库可以避免在 DB 迁移部署期间(和之后)的停机时间和性能问题。GitLab 的 migration_helpers.rb
https://gitlab.com/gitlab-org/gitlab-foss/blob/master/lib/gitlab/database/migration_helpers.rb 就是这样一套辅助工具的典范。向他人学习并分享你的知识 如果你有其他想法,可以在上面的列表中提及,请给我发邮件( nik@postgres.ai
)或在 Twitter 上联系我:@samokhvalov;我很乐意讨论这个问题。